Seis patrones de diseño de sistemas para escalar sin romperse

Montar una aplicación que funcione es una cosa y que aguante el crecimiento, los picos de tráfico y los fallos de las piezas que no controlas es otra bien distinta. El diseño de sistemas consiste en lo segundo, y casi siempre se reduce a elegir la solución más simple que resuelva el problema que tienes delante, no la más vistosa ni la que más sale en las charlas.

Esta guía junta seis familias de patrones que cubren buena parte de lo que se encuentra un equipo de backend cuando algo empieza a ir lento o a romperse: escalar lecturas, escalar escrituras, separar ambas con CQRS, enviar datos en tiempo real, sacar el trabajo pesado de la petición y aguantar fallos. Para cada una va el criterio de cuándo aplicarla y qué se paga a cambio.

1. Escalar las lecturas

En la mayoría de aplicaciones las lecturas superan con mucho a las escrituras, a menudo el 90 % del tráfico o más. Por eso, cuando el sistema se ralentiza, lo razonable es empezar por ahí: suele ser la mejora más barata.

Índices bien elegidos

Sin el índice adecuado, la base de datos recorre muchas más filas de las necesarias. Antes de añadir nada, mira el plan de ejecución de la consulta lenta:

EXPLAIN
SELECT *
FROM orders
WHERE user_id = 42 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;

Si el plan muestra un recorrido completo de una tabla grande, un índice compuesto que coincida con los filtros y con la ordenación suele bastar:

CREATE INDEX CONCURRENTLY idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);

Cuatro reglas que ahorran disgustos:

  • El orden de las columnas importa: primero las de igualdad, al final las de rango u ordenación.
  • Los índices parciales (por ejemplo, solo las filas con status = 'pending') son muy eficientes cuando consultas siempre el mismo subconjunto.
  • Cada índice encarece las escrituras, así que conviene borrar los que no se usan. En PostgreSQL la vista pg_stat_user_indexes te dice cuáles no reciben lecturas.
  • En PostgreSQL de producción, crea los índices con CONCURRENTLY; si no, la tabla queda bloqueada para escrituras mientras se construye.

Caché en tres niveles

Cuanto más cerca del usuario se resuelve la petición, menos cuesta:

  • Caché del cliente: el navegador o la app guardan la respuesta y las visitas repetidas no llegan al servidor (cabeceras Cache-Control y ETag).
  • CDN: los nodos de borde sirven el contenido desde un punto cercano y descargan al servidor de origen. Cloudflare es el ejemplo típico.
  • Caché de aplicación: Redis o Memcached entre la aplicación y la base de datos, para evitar repetir consultas caras.

La caché trae su propio problema, que es invalidarla. Un TTL corto es la opción más sencilla y a menudo suficiente; invalidar de forma explícita al escribir da datos más frescos, pero exige más disciplina.

Réplicas de lectura

Una sola base de datos acaba siendo cuello de botella. Con réplicas, el primario atiende las escrituras y las réplicas absorben las lecturas. El precio es el retraso de replicación: un usuario edita su perfil, la web lo redirige, la lectura cae en una réplica que aún no ha recibido el cambio y ve datos antiguos.

Hay tres formas de mitigarlo:

  • Leer tus propias escrituras: durante unos segundos tras una escritura, las lecturas de ese usuario van al primario.
  • Mandar a las réplicas solo lo que tolera datos algo viejos: informes, listados, búsquedas.
  • Vigilar el retraso: si supera un umbral aceptable, las lecturas vuelven al primario.

2. Escalar las escrituras

Las escrituras son más difíciles. Tienen que pasar por una única fuente de verdad para mantener la consistencia, y a diferencia de las lecturas no se resuelven con una caché.

Agrupar en lotes

Insertar registro a registro es caro sobre todo por los viajes de ida y vuelta y la sobrecarga de cada transacción, que muchas veces pesan más que el propio insert. Agrupar es la optimización más barata:

-- una fila por sentencia
INSERT INTO events (user_id, type) VALUES (1, 'login');

-- varias filas en una sola
INSERT INTO events (user_id, type) VALUES (1, 'login'), (2, 'login'), (3, 'view');

Con lotes grandes conviene fijar un tamaño razonable (unos cientos o pocos miles de filas, según el caso) para no generar transacciones larguísimas, consumo excesivo de memoria o timeouts.

Escrituras asíncronas

No toda escritura necesita terminar antes de responder al usuario. Se acepta la petición, se devuelve enseguida y el dato se guarda después a través de una cola:

cliente -> API -> cola -> worker -> base de datos
            (202 Accepted)

La cola amortigua los picos y deja que la base de datos trabaje al ritmo que puede soportar. A cambio, el dato no está disponible al instante, y el cliente debe saberlo.

Sharding

El sharding reparte los datos entre varias bases de datos (shards), cada una con una parte de la carga. Es la opción más potente y también la más costosa de operar:

  • La clave de shard importa: debe tener muchos valores distintos y repartirse de forma uniforme, como user_id o tenant_id. Una clave como created_at manda casi todas las escrituras nuevas al mismo shard.
  • Las operaciones entre shards se complican: los joins y las transacciones que cruzan shards son más difíciles o directamente imposibles.
  • Hay que prever el resharding: mover datos entre shards es un proyecto de migración serio. El hashing consistente o un servicio de directorio lo facilitan.

Antes de llegar aquí, agota las alternativas más simples: mejor hardware, índices, particionado de tablas y réplicas de lectura.

3. Separar lecturas y escrituras: CQRS

CQRS (Command Query Responsibility Segregation) separa las operaciones que cambian datos de las que los leen, para optimizar cada lado por su cuenta:

  • Lado de comandos: escrituras sobre un modelo normalizado y con transacciones.
  • Lado de consultas: datos desnormalizados o resultados precalculados, pensados para búsquedas, paneles o feeds.

Tiene sentido cuando leer y escribir necesitan estructuras de datos o formas de procesamiento muy distintas. En un CRUD sencillo sobra. Además introduce consistencia eventual: entre que se escribe un cambio y aparece en el modelo de lectura pasa un tiempo, y el producto tiene que convivir con eso.

4. Datos en tiempo real

Chats, paneles en vivo o seguimiento de repartos obligan a decidir dos cosas: con qué rapidez deben llegar las actualizaciones y si la comunicación es en un solo sentido o en los dos.

Long polling

El cliente hace una petición HTTP normal y el servidor la mantiene abierta hasta que hay datos nuevos o salta un timeout; entonces el cliente lanza otra. Es fácil de implementar, funciona con casi cualquier infraestructura y encaja con notificaciones o estados que no cambian cada segundo.

Server-Sent Events (SSE)

El servidor empuja eventos al cliente por una única conexión HTTP de larga duración, sin que el cliente tenga que repetir la petición. Va bien para paneles en vivo, notificaciones y feeds, donde el flujo es solo del servidor al cliente. El navegador reconecta automáticamente a través de EventSource. Con HTTP/1.1 hay un límite de unas seis conexiones por dominio, un detalle que desaparece con HTTP/2.

WebSockets

Abren una conexión persistente y bidireccional: cliente y servidor se envían mensajes en cualquier momento sin abrir una conexión nueva cada vez. Son la opción para chats, juegos multijugador y herramientas colaborativas. Cuestan más de operar, porque cada conexión abierta ocupa recursos en el servidor y hay que gestionar reconexiones y balanceo con conexiones largas.

Una regla práctica: si solo necesitas que el servidor avise, empieza por SSE; si hace falta tráfico en ambos sentidos y con frecuencia, WebSockets.

5. Trabajos de larga duración

El trabajo lento no debe ejecutarse dentro de la petición. Se encola, se devuelve un identificador del trabajo, se procesa en segundo plano y el cliente consulta el estado cuando quiera. Así las peticiones siguen siendo rápidas y las tareas largas no acaparan los recursos de la aplicación.

Colas de mensajes

Una cola guarda las tareas hasta que un worker puede procesarlas. RabbitMQ, Amazon SQS o Kafka cubren este papel, con matices: Kafka es más bien un registro de eventos distribuido que una cola clásica. Tres reglas:

  • Cuenta con duplicados: muchas colas garantizan entrega al menos una vez, así que el consumidor debe tolerar recibir el mismo mensaje dos veces.
  • Configura una dead-letter queue (DLQ): los mensajes que fallan una y otra vez se apartan en lugar de reintentarse eternamente.
  • Vigila la profundidad de la cola y la antigüedad del mensaje más viejo: si ambas crecen, faltan workers.

Pools de workers

Varios workers procesan trabajos a la vez y se pueden añadir más según crezca la demanda. Lo importante es limitar la concurrencia, porque demasiados workers pueden hundir la base de datos o el servicio externo del que dependen. En Rust con Tokio, un semáforo basta para acotarla:

let sem = Arc::new(Semaphore::new(32));
while let Some(job) = rx.recv().await {
    let permit = sem.clone().acquire_owned().await?;
    tokio::spawn(async move {
        let _ = handle(job).await;
        drop(permit);
    });
}

Añade timeouts y reintentos para que un trabajo atascado no ocupe un worker para siempre, y escala según la profundidad de la cola y el tiempo de proceso, sin pasar de lo que aguanten los servicios de los que dependes.

Motores de flujos de trabajo

Cuando un proceso tiene varios pasos, dura horas o días y necesita acciones de recuperación si algo falla, una cola con unos cuantos campos de estado se vuelve inmanejable. Temporal y AWS Step Functions gestionan flujos de larga duración, y Apache Airflow encaja mejor en pipelines de datos programados y procesos por lotes. Ofrecen estado duradero, reintentos, temporizadores y visibilidad de cada paso. A cambio, hay más infraestructura y un modelo de programación nuevo que aprender, así que para un trabajo de dos pasos no compensan.

6. Fiabilidad

Los servidores se caen, las redes fallan y los servicios externos dejan de responder. Un sistema fiable se diseña asumiendo que eso va a pasar.

Reintentos con backoff

Muchos fallos son transitorios, así que reintentar tras una breve espera suele resolverlos. El backoff exponencial va alargando el intervalo entre intentos y el jitter lo aleatoriza, para que miles de clientes no reintenten todos en el mismo instante:

delay = min(cap, base * 2 ** attempt)
time.sleep(random.uniform(0, delay))
  • Reintenta solo errores transitorios, como timeouts o errores 5xx temporales.
  • Fija un número máximo de intentos y un tiempo total límite.
  • Usa jitter para evitar tormentas de reintentos.
  • Haz las operaciones idempotentes, para que repetirlas no duplique efectos.

Idempotencia

Los reintentos solo son seguros si repetir una operación no cambia el resultado. Un pago puede haberse completado aunque el cliente nunca recibiera la respuesta, y reintentarlo sin protección cobraría dos veces. La solución habitual es una clave de idempotencia: el servidor guarda el resultado asociado a la clave y, si llega la misma petición, devuelve ese resultado en lugar de procesarla de nuevo.

POST /payments
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7

Lo mismo vale para los consumidores de mensajes: deduplica por identificador o diseña operaciones naturalmente idempotentes, como fijar un valor o hacer un upsert, en lugar de incrementar un contador cada vez.

Circuit breakers

Si un servicio falla de forma continuada, seguir llamándolo desperdicia recursos y propaga el fallo al resto del sistema. Un circuit breaker corta temporalmente las llamadas para darle tiempo a recuperarse, y tiene tres estados:

  • Cerrado: las peticiones fluyen con normalidad.
  • Abierto: las peticiones fallan al instante sin llamar a la dependencia.
  • Semiabierto: pasan unas pocas peticiones de prueba para comprobar si ya se ha recuperado.

Funciona mejor junto a una alternativa: datos en caché, valores por defecto o una función desactivada temporalmente.

Recuperación automática

Un sistema fiable se recupera de los fallos habituales sin esperar a que alguien lo arregle a mano:

  • Health checks: la comprobación de liveness dice si hay que reiniciar la aplicación; la de readiness, si debe recibir tráfico. No son lo mismo y confundirlas provoca reinicios en cascada.
  • Reinicios automáticos: systemd, Docker Swarm o Kubernetes reinician procesos caídos y reprograman cargas.
  • Failover de base de datos: cambio automático a una réplica sana cuando cae el primario. Pruébalo con regularidad, porque un failover que nunca se ha ejercitado suele fallar el día que hace falta.
  • Autoescalado: añadir o quitar capacidad según señales reales de demanda, como el retraso de la cola o la latencia de las peticiones, y no solo la CPU.

Cómo usar todo esto

Ningún patrón es obligatorio. Lo habitual es avanzar por orden de coste: índices y caché antes que réplicas, réplicas antes que sharding, una cola simple antes que un motor de workflows. Cada uno resuelve un problema concreto y añade complejidad operativa, así que conviene medir primero dónde está el cuello de botella y aplicar solo lo que lo ataca.

Imagen: Pexels / Brett Sayles

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP